Skip to content

Bump aiosendspin to 9.1.1 and wire up pairing/encryption - #277

Open
OnFreund wants to merge 4 commits into
Sendspin:mainfrom
OnFreund:modernize-aiosendspin-pairing
Open

Bump aiosendspin to 9.1.1 and wire up pairing/encryption#277
OnFreund wants to merge 4 commits into
Sendspin:mainfrom
OnFreund:modernize-aiosendspin-pairing

Conversation

@OnFreund

@OnFreund OnFreund commented Sep 3, 2026

Copy link
Copy Markdown

sendspin-python-cli was pinned to aiosendspin~=6.0.1, three major versions and 77 commits behind, and never adopted the encryption/pairing subsystem that landed in that gap. This bumps the dependency to 9.1.1 and:

  • Persists a per-client Identity (X25519 keypair) and FileClientPairingStore under --settings-dir (now available for the TUI too, not just daemon).
  • Wires PairingSupport into both SendspinClient constructions (TUI, daemon), rendering a dynamic pairing PIN in a new "Pairing Required" TUI panel or logging it in daemon mode.
  • Fixes everything else the version bump broke: the client-state enum removal, server/hello restructuring, ServerInfo/GoodbyeReason changes, and the server-side identity/pairing_store constructor change (affects sendspin serve too).
  • Fixes a real bug surfaced by live testing against a Music Assistant server: SendspinClient.attach_websocket() now blocks for the connection's entire lifetime once admitted (previously it returned right after handshake), so the daemon's server-initiated listener never reached its own post-handshake audio/MPRIS wiring. Audio chunks flowed at the protocol level but never reached local playback. Fixed by running attach_websocket() as a background task and polling client.connected, matching the pattern aiosendspin's own tests use for this scenario.

sendspin-python-cli was pinned to aiosendspin~=6.0.1, three major versions
and 77 commits behind, and never adopted the encryption/pairing subsystem
that landed in that gap. This bumps the dependency to 9.1.1 and:

- Persists a per-client Identity (X25519 keypair) and FileClientPairingStore
  under --settings-dir (now available for the TUI too, not just daemon).
- Wires PairingSupport into both SendspinClient constructions (TUI, daemon),
  rendering a dynamic pairing PIN in a new "Pairing Required" TUI panel or
  logging it in daemon mode.
- Fixes everything else the version bump broke: the client-state enum
  removal, server/hello restructuring, ServerInfo/GoodbyeReason changes,
  and the server-side identity/pairing_store constructor change (affects
  `sendspin serve` too).
- Fixes a real bug surfaced by live testing against a Music Assistant
  server: SendspinClient.attach_websocket() now blocks for the connection's
  entire lifetime once admitted (previously it returned right after
  handshake), so the daemon's server-initiated listener never reached its
  own post-handshake audio/MPRIS wiring. Audio chunks flowed at the
  protocol level but never reached local playback. Fixed by running
  attach_websocket() as a background task and polling client.connected,
  matching the pattern aiosendspin's own tests use for this scenario.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@OnFreund
OnFreund requested a review from balloob September 3, 2026 13:53
Spell out that the daemon-mode PIN is log-only (journalctl under systemd)
with no unattended-pairing surface, since that's easy to miss compared to
the TUI's dedicated panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@chriscn

chriscn commented Sep 7, 2026

Copy link
Copy Markdown

I think the upgrade path for a previously installed and paired server is broken:

I ran the following:

uv tool uninstall sendspin
uv tool install "git+https://github.com/OnFreund/sendspin-python-cli.git@modernize-aiosendspin-pairing"

I get the following logs:

Sep 07 17:28:11 living-room systemd[1]: Started sendspin.service - Sendspin Multi-Room Audio Client.
Sep 07 17:28:14 living-room sendspin[43322]: INFO:sendspin.audio_devices:Using audio device 1: RPi DigiAMP+: Raspberry Pi DigiAMP+ HiFi pcm512x-hifi-0 (hw:2,0)
Sep 07 17:28:14 living-room sendspin[43322]: INFO:sendspin.cli:Using ALSA mixer volume control: card 2, element 'Digital'
Sep 07 17:28:14 living-room sendspin[43322]: INFO:sendspin.audio:C volume extension unavailable; falling back to numpy (slower)
Sep 07 17:28:15 living-room sendspin[43322]: INFO:sendspin.daemon.daemon:Starting Sendspin daemon: gWJGPmeWtQRJjW15I7vJ2nqOzK31MmK7J9uk94kZsw4
Sep 07 17:28:15 living-room sendspin[43322]: INFO:sendspin.daemon.daemon:Listening for server connections on port 8928 (mDNS: _sendspin._tcp.local.)
Sep 07 17:28:15 living-room sendspin[43322]: INFO:aiosendspin.client.listener:ClientListener started on port 8928, path /sendspin
Sep 07 17:28:16 living-room sendspin[43322]: INFO:aiosendspin.client.listener:mDNS advertising client 'gWJGPmeWtQRJjW15I7vJ2nqOzK31MmK7J9uk94kZsw4' on port 8928 with path /sendspin
Sep 07 17:28:29 living-room sendspin[43322]: INFO:sendspin.daemon.daemon:Server connected
Sep 07 17:28:29 living-room sendspin[43322]: INFO:sendspin.audio_devices:Detected 32 supported audio formats (FLAC + PCM)
Sep 07 17:28:29 living-room sendspin[43322]: INFO:aiohttp.access:192.168.0.6 [07/Sep/2026:17:28:29 +0100] "GET /sendspin HTTP/1.1" 101 0 "-" "Music Assistant/2.10.2 aiohttp/3.14.3 Python/3.14"

But nothing plays out of the speakers.

Addresses feedback on PR Sendspin#277: upgrading from a pre-pairing install
replaces the old free-text client_id with one derived from a generated
identity's public key, so the client now looks like a new device to any
server that keyed player state off the old client_id. That change itself
can't be avoided (client_id is cryptographically bound to the identity's
keypair, not an arbitrary label), but:

- Log a one-time warning when a fresh identity is generated for a settings
  dir that already had an old-style client_id, explaining what happened
  and that the player may need to be re-added on the server.
- Populate DeviceInfo.mac_address (a stable hardware identifier the
  protocol already supports but this client never sent) so servers that
  support device-based reconciliation have something durable to key off,
  independent of client_id.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@OnFreund

OnFreund commented Sep 7, 2026

Copy link
Copy Markdown
Author

Thanks for testing this against a real Music Assistant setup — really helpful.

I think what you're hitting is a real (and somewhat unavoidable) consequence of adding
encryption/pairing, not a bug in the streaming path itself: client_id used to be a
stable, often user-chosen string (sendspin-cli-<hostname>, or --id). With pairing, it's
now derived from a generated cryptographic identity's public key instead, since that's
what the Noise handshake actually authenticates. That means upgrading a previously-running
install makes the client look like a brand-new device to Music Assistant — your old
player record (and anything pointed at it — zones, groups, HA entities) doesn't carry
over, since MA keys its player registry by client_id. There's no way to make the new
identity's public key equal the old free-text string; they're fundamentally different
kinds of value.

I just pushed two things that address what's actually in our control here:

  • A one-time warning logged when a fresh identity replaces an old-style client_id,
    explaining what happened.
  • DeviceInfo.mac_address is now populated (the protocol already had the field, we just
    weren't sending it) — a stable hardware identifier independent of client_id, in case
    MA's newer versions do any device-based reconciliation with it.

That said, I want to make sure we're not also looking at a second, separate bug: your log
stops right after the WS upgrade (GET /sendspin ... 101) — no "Pairing required" PIN, no
"Stream started". Could you confirm a couple of things?

  1. Did a new player show up in Music Assistant's device list, distinct from your
    previously-configured one?
  2. If you select that new player and try to play something, does audio come through, or
    does it stay silent even then?
  3. Does the log ever progress past that WS-upgrade line if you wait a bit longer (a pairing
    PIN, a "Stream started" line, or a timeout warning), or does it just stay silent forever?

If audio genuinely doesn't work even on the new player once selected, that's a distinct bug
from the identity-continuity issue and I'd like to chase it down.

🤖 Generated with Claude Code

@mrwsl

mrwsl commented Sep 10, 2026

Copy link
Copy Markdown

Tested 7ab18c1 against Music Assistant 2.10.2 (HAOS add-on, server-side aiosendspin 9.1.1). Audio works, and it fixes a real failure with the released CLI. Details below, including a silent first-connection hang that may explain the earlier report above.

Setup: Raspberry Pi 4B, HiFiBerry Amp100 (pcm512x), Debian 13 (trixie), kernel 6.18.39+rpt-rpi-v8, Python 3.13, client aiosendspin 9.1.1, output via PipeWire 1.4.2 (Using audio device 2: pipewire). Wired Ethernet; 0% loss / 0.34 ms from the MA host.

Baseline: released sendspin 7.5.0 (aiosendspin 6.0.x) against MA 2.10.2

Playback starts, then times out within seconds, repeatedly. Client side, every server/state fails to parse:

ERROR:aiosendspin.client.client:Failed to parse server message: {"payload":{"controller":{...
ValueError: 'seek_relative' is not a valid MediaCommand
mashumaro.exceptions.InvalidFieldValue: Field "supported_commands" of type list[MediaCommand] ...

Server side, only this client (other Sendspin clients on the same server were fine):

Slow send_bytes: 17054.0ms size=22401 ... role=player
Late binary type=4 role=player: skipping 1 chunk(s); late_by_us=14083238 ... buf_ms=0
WebSocket error: No PONG received after 15.0 seconds
WebSocket closed, close_code=1006

The network was clean throughout, so the client was effectively not reading its socket.

With this PR: answers to your three questions

  1. New player? Yes. MA registered mk87gsg…/sendspin-arbeitszimmer and a new universal player. It reuses the same display name as the old, now-unavailable record, so the two are easy to confuse in MA's player list. On first glance it looked like no new player had appeared.
  2. Audio on the new player? Yes. Stream started with codec flac, 48 kHz/24-bit, audible, and MA's send queue to the client stays empty with no stall warnings.
  3. Does the log progress past the WS upgrade? Yes, but only after a ~64 s silent hang:
19:46:09  Server connected / GET /sendspin 101       <- first server-initiated connection
          (nothing: no MPRIS start, MA never registers the player, neither side logs an error)
19:47:13  Server connected                           <- MA reconnects
19:47:14  MPRIS interface started                    <- post-handshake wiring runs this time
19:47:14  [MA] Player (type protocol) registered
19:48:58  Pairing required: enter PIN 108498 on the server.
19:49:08  Paired with server ... via dynamic_pin
19:49:58  Stream started with codec flac

The first connection completed the WebSocket upgrade and then stalled silently until MA retried. @chriscn, your log stops at exactly the same 101 line. It may be worth waiting a minute or two to see whether a second Server connected follows.

Longer run: ~5 minutes of playback with 10 stream starts from skips and play/pause. Every one logs Stream STARTED: 3 chunks, 0.29 seconds buffered, in the same second MA logs Start Queue Flow stream. Volume changes arrive promptly as Server set player volume. Zero warnings or errors in the client journal, none of the stall/PONG/disconnect warnings on the server, NRestarts=0, and the server's send queue to the client stays empty with no retransmits. Controls feel responsive.

PIN entry: MA didn't prompt by itself. The new player showed up needing to be reconfigured, and clicking that opened MA's PIN dialog; the client logged the PIN at that moment and paired 10 s later. So the ~105 s between connecting and the PIN request was me finding that prompt, not a client-side delay.

mrwsl's PR Sendspin#277 test found a ~64s silent stall on the first incoming
connection before the server retries and admission succeeds. Root cause:
aiosendspin's own Noise handshake timeout (30s) is caught internally by
attach_websocket() and returns normally rather than raising, so our
polling loop's exc-is-not-None check never fires and we return with no
log line at all. Add an info-level log for that branch so a stalled
first attempt is visible instead of looking identical to nothing
happening.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@OnFreund

Copy link
Copy Markdown
Author

@mrwsl thank you for this — genuinely one of the most thorough test reports I've seen, exactly the confirmation this needed. Great to hear audio holds up cleanly over a real 5-minute session with skips/pause/volume changes and no stalls.

Your first-connection trace was really useful: the ~64s silent gap before MA's retry is caused by aiosendspin's own Noise handshake timeout (30s) being caught internally by attach_websocket() and returning normally instead of raising — so our polling loop had nothing to log. Pushed 875d4b6, which adds a log line for that case (Incoming connection did not complete admission...) so a stalled first attempt is now visible instead of looking identical to nothing happening.

@chriscn — this may well be what you hit too: your log cuts off at exactly the same GET /sendspin ... 101 point mrwsl's did, right before their ~64s stall. Could you retest against the latest commit and either wait a couple of minutes to see if a second Server connected follows, or check for the new log line if it stalls again? That'll tell us whether it's the same self-recovering timeout or something else.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants